Skip to main content

oxigdal_algorithms/vector/
ransac.rs

1//! RANSAC robust model fitting (lines and planes).
2//!
3//! RANSAC (RANdom SAmple Consensus) is an iterative method for estimating the
4//! parameters of a mathematical model from a set of observed data that contains
5//! a large fraction of outliers. This module provides robust fitting of:
6//!
7//! - 2D lines (`a x + b y + c = 0`, normalized `a² + b² = 1`) via
8//!   [`ransac_fit_line`].
9//! - 3D planes (`a x + b y + c z + d = 0`, normalized `a² + b² + c² = 1`) via
10//!   [`ransac_fit_plane`].
11//!
12//! The implementation follows the classic Fischler–Bolles algorithm with an
13//! adaptive stopping criterion: the required number of iterations is recomputed
14//! whenever a better consensus set is found, so clean data terminates quickly
15//! while noisy data exhausts the iteration budget.
16//!
17//! ## Robustness details
18//!
19//! The fit complements Slice 21's Weiszfeld `geometric_median`: where the
20//! geometric median is robust to outliers in a *location* estimate, RANSAC is
21//! robust to outliers in a *parametric model* estimate. After the consensus set
22//! is found, the model is refined with total (orthogonal) least squares on the
23//! inliers, which minimizes the perpendicular distances rather than the
24//! vertical residuals — appropriate when there is no privileged axis.
25//!
26//! ## Determinism
27//!
28//! No external RNG is used. Sampling relies on a deterministic Knuth MMIX linear
29//! congruential generator seeded by [`RansacOptions::seed`], so identical inputs
30//! and seeds always produce identical results (see the seed-reproducibility
31//! tests). This honors the project's SciRS2 policy (no `rand` / `rand_distr`).
32//!
33//! ## Example
34//!
35//! ```
36//! use oxigdal_algorithms::vector::{Point, RansacOptions, ransac_fit_line};
37//!
38//! // Points on the line y = 2x + 1 with one gross outlier.
39//! let points = [
40//!     Point::new(0.0, 1.0),
41//!     Point::new(1.0, 3.0),
42//!     Point::new(2.0, 5.0),
43//!     Point::new(3.0, 7.0),
44//!     Point::new(4.0, 9.0),
45//!     Point::new(2.0, 50.0), // outlier
46//! ];
47//! let result = ransac_fit_line(&points, &RansacOptions::default())?;
48//! assert!(result.converged);
49//! assert!(!result.inliers.contains(&5)); // outlier rejected
50//! # Ok::<(), oxigdal_algorithms::error::AlgorithmError>(())
51//! ```
52
53use crate::error::AlgorithmError;
54use oxigdal_core::vector::Point;
55
56/// Knuth MMIX LCG multiplier (matches the convention used elsewhere in the
57/// workspace, e.g. `oxigdal-streaming` retry jitter and the `oxigdal-index`
58/// test harness).
59const LCG_MULTIPLIER: u64 = 6_364_136_223_846_793_005;
60
61/// Knuth MMIX LCG increment.
62const LCG_INCREMENT: u64 = 1_442_695_040_888_963_407;
63
64/// Tolerance for treating sampled points as coincident / collinear when forming
65/// a candidate model.
66const DEGENERATE_EPSILON: f64 = 1e-12;
67
68/// Configuration for RANSAC fitting.
69///
70/// Use [`RansacOptions::default`] for sensible defaults and override individual
71/// fields as needed.
72#[derive(Debug, Clone, PartialEq)]
73pub struct RansacOptions {
74    /// Maximum number of sampling iterations.
75    pub max_iterations: usize,
76    /// Maximum distance from the model for a point to be counted as an inlier.
77    pub inlier_threshold: f64,
78    /// Minimum number of inliers required for the result to be considered valid.
79    pub min_inlier_count: usize,
80    /// Seed for the deterministic Knuth MMIX LCG used to draw samples.
81    pub seed: u64,
82    /// Desired probability that at least one outlier-free sample is drawn; used
83    /// by the adaptive stopping criterion.
84    pub confidence: f64,
85}
86
87impl Default for RansacOptions {
88    fn default() -> Self {
89        Self {
90            max_iterations: 1000,
91            inlier_threshold: 1e-3,
92            min_inlier_count: 2,
93            seed: 0xDEAD_BEEF,
94            confidence: 0.99,
95        }
96    }
97}
98
99/// Result of a RANSAC fit.
100///
101/// `M` is the fitted model type ([`RansacLineModel`] or [`RansacPlaneModel`]).
102#[derive(Debug, Clone)]
103pub struct RansacResult<M> {
104    /// The best model found.
105    pub model: M,
106    /// Indices (into the input slice) of the inliers supporting `model`.
107    pub inliers: Vec<usize>,
108    /// Number of sampling iterations actually performed.
109    pub iterations: usize,
110    /// Whether the consensus set reached `min_inlier_count`.
111    pub converged: bool,
112}
113
114/// A 2D line in implicit form `a x + b y + c = 0` with the normal `(a, b)`
115/// normalized so that `a² + b² = 1`.
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct RansacLineModel {
118    /// `x` coefficient of the normal.
119    pub a: f64,
120    /// `y` coefficient of the normal.
121    pub b: f64,
122    /// Signed offset.
123    pub c: f64,
124}
125
126impl RansacLineModel {
127    /// Builds a line through two points.
128    ///
129    /// Returns `None` when the points are coincident (within
130    /// `DEGENERATE_EPSILON`). The normal `(a, b)` is perpendicular to the
131    /// direction `p2 - p1`.
132    #[must_use]
133    pub fn from_two_points(p1: Point, p2: Point) -> Option<Self> {
134        let dx = p2.x() - p1.x();
135        let dy = p2.y() - p1.y();
136        let norm = (dx * dx + dy * dy).sqrt();
137        if !norm.is_finite() || norm <= DEGENERATE_EPSILON {
138            return None;
139        }
140        // Normal is perpendicular to the direction (dx, dy): (-dy, dx).
141        let a = -dy / norm;
142        let b = dx / norm;
143        let c = -(a * p1.x() + b * p1.y());
144        Some(Self { a, b, c })
145    }
146
147    /// Perpendicular distance from a point to the line (`a`, `b` normalized).
148    #[must_use]
149    pub fn distance_to(&self, p: Point) -> f64 {
150        (self.a * p.x() + self.b * p.y() + self.c).abs()
151    }
152
153    /// Unit direction vector of the line: `(-b, a)`.
154    #[must_use]
155    pub fn direction(&self) -> (f64, f64) {
156        (-self.b, self.a)
157    }
158
159    /// The point on the line closest to the origin: `(-a*c, -b*c)`.
160    #[must_use]
161    pub fn point_on_line(&self) -> (f64, f64) {
162        (-self.a * self.c, -self.b * self.c)
163    }
164}
165
166/// A 3D plane in implicit form `a x + b y + c z + d = 0` with the normal
167/// `(a, b, c)` normalized so that `a² + b² + c² = 1`.
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct RansacPlaneModel {
170    /// `x` coefficient of the normal.
171    pub a: f64,
172    /// `y` coefficient of the normal.
173    pub b: f64,
174    /// `z` coefficient of the normal.
175    pub c: f64,
176    /// Signed offset.
177    pub d: f64,
178}
179
180impl RansacPlaneModel {
181    /// Builds a plane through three points.
182    ///
183    /// Returns `None` when the points are collinear (cross product magnitude
184    /// within `DEGENERATE_EPSILON`).
185    #[must_use]
186    pub fn from_three_points(
187        p1: (f64, f64, f64),
188        p2: (f64, f64, f64),
189        p3: (f64, f64, f64),
190    ) -> Option<Self> {
191        let v1 = (p2.0 - p1.0, p2.1 - p1.1, p2.2 - p1.2);
192        let v2 = (p3.0 - p1.0, p3.1 - p1.1, p3.2 - p1.2);
193        // Normal = v1 × v2.
194        let nx = v1.1 * v2.2 - v1.2 * v2.1;
195        let ny = v1.2 * v2.0 - v1.0 * v2.2;
196        let nz = v1.0 * v2.1 - v1.1 * v2.0;
197        let norm = (nx * nx + ny * ny + nz * nz).sqrt();
198        if !norm.is_finite() || norm <= DEGENERATE_EPSILON {
199            return None;
200        }
201        let a = nx / norm;
202        let b = ny / norm;
203        let c = nz / norm;
204        let d = -(a * p1.0 + b * p1.1 + c * p1.2);
205        Some(Self { a, b, c, d })
206    }
207
208    /// Perpendicular distance from a point to the plane (`a`, `b`, `c`
209    /// normalized).
210    #[must_use]
211    pub fn distance_to(&self, p: (f64, f64, f64)) -> f64 {
212        (self.a * p.0 + self.b * p.1 + self.c * p.2 + self.d).abs()
213    }
214
215    /// Unit normal vector of the plane: `(a, b, c)`.
216    #[must_use]
217    pub fn normal(&self) -> (f64, f64, f64) {
218        (self.a, self.b, self.c)
219    }
220}
221
222/// Advances a Knuth MMIX LCG state in place and returns the new state.
223///
224/// `next = state * 6364136223846793005 + 1442695040888963407` (wrapping).
225/// This matches the convention used by `oxigdal-streaming::cloud::retry` and
226/// the `oxigdal-index` test harness.
227#[inline]
228fn lcg_next(state: &mut u64) -> u64 {
229    *state = state
230        .wrapping_mul(LCG_MULTIPLIER)
231        .wrapping_add(LCG_INCREMENT);
232    *state
233}
234
235/// Draws a uniform index in `[0, n)` from the LCG.
236///
237/// Uses the upper 31 bits of the advanced state (the best-mixed bits of an LCG)
238/// before reducing modulo `n`. Precondition: `n >= 1`.
239#[inline]
240fn lcg_index(n: usize, state: &mut u64) -> usize {
241    let raw = lcg_next(state);
242    ((raw >> 33) as usize) % n
243}
244
245/// Draws two distinct indices in `[0, n)` via rejection sampling.
246///
247/// Precondition: `n >= 2`.
248fn sample_two_distinct(n: usize, state: &mut u64) -> (usize, usize) {
249    let i = lcg_index(n, state);
250    let mut j = lcg_index(n, state);
251    while j == i {
252        j = lcg_index(n, state);
253    }
254    (i, j)
255}
256
257/// Draws three distinct indices in `[0, n)` via rejection sampling.
258///
259/// Precondition: `n >= 3`.
260fn sample_three_distinct(n: usize, state: &mut u64) -> (usize, usize, usize) {
261    let i = lcg_index(n, state);
262    let mut j = lcg_index(n, state);
263    while j == i {
264        j = lcg_index(n, state);
265    }
266    let mut k = lcg_index(n, state);
267    while k == i || k == j {
268        k = lcg_index(n, state);
269    }
270    (i, j, k)
271}
272
273/// Computes the adaptive iteration count for RANSAC.
274///
275/// `N = log(1 - confidence) / log(1 - inlier_ratio^sample_size)`, clamped to
276/// `[1, max]`.
277///
278/// Guards:
279/// - `inlier_ratio >= 1.0` ⇒ `1` (a single clean sample suffices; avoids
280///   `log(0) = -inf` and the resulting `NaN`).
281/// - `inlier_ratio <= 0.0` ⇒ `max` (no inliers known; exhaust the budget).
282/// - any non-finite intermediate ⇒ `max`.
283fn adaptive_iteration_count(
284    inlier_ratio: f64,
285    sample_size: usize,
286    confidence: f64,
287    max: usize,
288) -> usize {
289    if max == 0 {
290        return 0;
291    }
292    if !inlier_ratio.is_finite() || inlier_ratio <= 0.0 {
293        return max;
294    }
295    if inlier_ratio >= 1.0 {
296        return 1;
297    }
298    // Clamp confidence into a usable open interval to keep the logarithms finite.
299    let conf = if !confidence.is_finite() {
300        return max;
301    } else {
302        confidence.clamp(0.0, 1.0 - f64::EPSILON)
303    };
304    let prob_all_inliers = inlier_ratio.powi(sample_size as i32);
305    let denom = (1.0 - prob_all_inliers).ln();
306    let numer = (1.0 - conf).ln();
307    if !denom.is_finite() || !numer.is_finite() || denom >= 0.0 {
308        // denom should be negative; if not (e.g. prob_all_inliers ~ 0 making
309        // 1 - p ~ 1, ln ~ 0), fall back to the full budget.
310        return max;
311    }
312    let raw = numer / denom;
313    if !raw.is_finite() {
314        return max;
315    }
316    let needed = raw.ceil();
317    if needed <= 1.0 {
318        1
319    } else if needed >= max as f64 {
320        max
321    } else {
322        needed as usize
323    }
324}
325
326/// Fits a line to `points` using total (orthogonal) least squares restricted to
327/// `indices`.
328///
329/// Returns `None` if fewer than two indices are supplied or the covariance is
330/// degenerate (all selected points coincide), so the caller can fall back to the
331/// sampled model.
332fn least_squares_line(points: &[Point], indices: &[usize]) -> Option<RansacLineModel> {
333    let n = indices.len();
334    if n < 2 {
335        return None;
336    }
337    let inv_n = 1.0 / n as f64;
338    let mut mean_x = 0.0;
339    let mut mean_y = 0.0;
340    for &idx in indices {
341        mean_x += points[idx].x();
342        mean_y += points[idx].y();
343    }
344    mean_x *= inv_n;
345    mean_y *= inv_n;
346
347    let mut sxx = 0.0;
348    let mut sxy = 0.0;
349    let mut syy = 0.0;
350    for &idx in indices {
351        let dx = points[idx].x() - mean_x;
352        let dy = points[idx].y() - mean_y;
353        sxx += dx * dx;
354        sxy += dx * dy;
355        syy += dy * dy;
356    }
357
358    // Degenerate spread: all points effectively coincide.
359    if sxx + syy <= DEGENERATE_EPSILON {
360        return None;
361    }
362
363    // Eigen-decomposition of the symmetric 2×2 covariance
364    // [[sxx, sxy], [sxy, syy]]. The line normal is the eigenvector of the
365    // *smaller* eigenvalue (the direction of least variance).
366    let trace_half = 0.5 * (sxx + syy);
367    let diff_half = 0.5 * (sxx - syy);
368    let radius = (diff_half * diff_half + sxy * sxy).sqrt();
369    let lambda_min = trace_half - radius;
370
371    // Eigenvector for lambda_min: solve (C - lambda_min I) v = 0. Use the more
372    // numerically stable of the two rows.
373    let (mut a, mut b) = {
374        let r0x = sxx - lambda_min;
375        let r0y = sxy;
376        let r1x = sxy;
377        let r1y = syy - lambda_min;
378        // The eigenvector is orthogonal to a non-zero row of (C - lambda I).
379        if r0x * r0x + r0y * r0y >= r1x * r1x + r1y * r1y {
380            (-r0y, r0x)
381        } else {
382            (-r1y, r1x)
383        }
384    };
385
386    let norm = (a * a + b * b).sqrt();
387    if !norm.is_finite() || norm <= DEGENERATE_EPSILON {
388        return None;
389    }
390    a /= norm;
391    b /= norm;
392    let c = -(a * mean_x + b * mean_y);
393    Some(RansacLineModel { a, b, c })
394}
395
396/// Multiplies a symmetric 3×3 matrix (stored as `[xx, xy, xz, yy, yz, zz]`) by a
397/// vector.
398#[inline]
399fn sym3_mul(m: &[f64; 6], v: (f64, f64, f64)) -> (f64, f64, f64) {
400    (
401        m[0] * v.0 + m[1] * v.1 + m[2] * v.2,
402        m[1] * v.0 + m[3] * v.1 + m[4] * v.2,
403        m[2] * v.0 + m[4] * v.1 + m[5] * v.2,
404    )
405}
406
407#[inline]
408fn vec3_norm(v: (f64, f64, f64)) -> f64 {
409    (v.0 * v.0 + v.1 * v.1 + v.2 * v.2).sqrt()
410}
411
412#[inline]
413fn vec3_normalize(v: (f64, f64, f64)) -> Option<(f64, f64, f64)> {
414    let n = vec3_norm(v);
415    if !n.is_finite() || n <= DEGENERATE_EPSILON {
416        None
417    } else {
418        Some((v.0 / n, v.1 / n, v.2 / n))
419    }
420}
421
422#[inline]
423fn vec3_cross(a: (f64, f64, f64), b: (f64, f64, f64)) -> (f64, f64, f64) {
424    (
425        a.1 * b.2 - a.2 * b.1,
426        a.2 * b.0 - a.0 * b.2,
427        a.0 * b.1 - a.1 * b.0,
428    )
429}
430
431#[inline]
432fn vec3_dot(a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 {
433    a.0 * b.0 + a.1 * b.1 + a.2 * b.2
434}
435
436/// Dominant eigenvector of a symmetric 3×3 matrix via power iteration.
437///
438/// Returns the (unit) eigenvector and its eigenvalue, or `None` if the matrix is
439/// effectively zero.
440fn dominant_eigenvector(m: &[f64; 6], seed: (f64, f64, f64)) -> Option<((f64, f64, f64), f64)> {
441    const MAX_ITER: usize = 50;
442    const TOL: f64 = 1e-12;
443    let mut v = vec3_normalize(seed).unwrap_or((1.0, 0.0, 0.0));
444    let mut prev_eigenvalue = 0.0;
445    for _ in 0..MAX_ITER {
446        let mv = sym3_mul(m, v);
447        // `?` propagates `None` when the matrix maps `v` to ~0 (near-zero matrix).
448        let next = vec3_normalize(mv)?;
449        // Rayleigh quotient gives the eigenvalue estimate.
450        let eigenvalue = vec3_dot(next, sym3_mul(m, next));
451        if (eigenvalue - prev_eigenvalue).abs() <= TOL * eigenvalue.abs().max(1.0) {
452            return Some((next, eigenvalue));
453        }
454        prev_eigenvalue = eigenvalue;
455        v = next;
456    }
457    let eigenvalue = vec3_dot(v, sym3_mul(m, v));
458    Some((v, eigenvalue))
459}
460
461/// Fits a plane to `points` using total least squares restricted to `indices`.
462///
463/// The plane normal is the eigenvector of the *smallest* eigenvalue of the 3×3
464/// covariance. Rather than risk an ill-conditioned inverse-power iteration, the
465/// two dominant eigenvectors are found by deflated power iteration and the
466/// normal is recovered as their cross product (which is, up to sign, the
467/// least-variance direction). Returns `None` for fewer than three indices or a
468/// degenerate (rank-deficient) covariance, so the caller can fall back.
469fn least_squares_plane(points: &[(f64, f64, f64)], indices: &[usize]) -> Option<RansacPlaneModel> {
470    let n = indices.len();
471    if n < 3 {
472        return None;
473    }
474    let inv_n = 1.0 / n as f64;
475    let mut mean = (0.0, 0.0, 0.0);
476    for &idx in indices {
477        let p = points[idx];
478        mean.0 += p.0;
479        mean.1 += p.1;
480        mean.2 += p.2;
481    }
482    mean.0 *= inv_n;
483    mean.1 *= inv_n;
484    mean.2 *= inv_n;
485
486    // Symmetric covariance stored as [xx, xy, xz, yy, yz, zz].
487    let mut cov = [0.0_f64; 6];
488    for &idx in indices {
489        let p = points[idx];
490        let dx = p.0 - mean.0;
491        let dy = p.1 - mean.1;
492        let dz = p.2 - mean.2;
493        cov[0] += dx * dx;
494        cov[1] += dx * dy;
495        cov[2] += dx * dz;
496        cov[3] += dy * dy;
497        cov[4] += dy * dz;
498        cov[5] += dz * dz;
499    }
500
501    let total_spread = cov[0] + cov[3] + cov[5];
502    if total_spread <= DEGENERATE_EPSILON {
503        return None;
504    }
505
506    // First dominant eigenvector.
507    let (v1, lambda1) = dominant_eigenvector(&cov, (1.0, 0.0, 0.0))?;
508
509    // Deflate: cov' = cov - lambda1 * v1 v1ᵀ, then find the next dominant
510    // eigenvector. The deflated matrix is still symmetric.
511    let deflated = [
512        cov[0] - lambda1 * v1.0 * v1.0,
513        cov[1] - lambda1 * v1.0 * v1.1,
514        cov[2] - lambda1 * v1.0 * v1.2,
515        cov[3] - lambda1 * v1.1 * v1.1,
516        cov[4] - lambda1 * v1.1 * v1.2,
517        cov[5] - lambda1 * v1.2 * v1.2,
518    ];
519    // Seed the second iteration with a vector orthogonal to v1 for faster and
520    // more reliable convergence away from v1.
521    let helper = if v1.0.abs() <= 0.9 {
522        (1.0, 0.0, 0.0)
523    } else {
524        (0.0, 1.0, 0.0)
525    };
526    let seed2 = vec3_cross(v1, helper);
527    let (v2, _lambda2) = dominant_eigenvector(&deflated, seed2)?;
528
529    // The least-variance direction is orthogonal to the two dominant ones.
530    let normal = vec3_normalize(vec3_cross(v1, v2))?;
531    let d = -(normal.0 * mean.0 + normal.1 * mean.1 + normal.2 * mean.2);
532    Some(RansacPlaneModel {
533        a: normal.0,
534        b: normal.1,
535        c: normal.2,
536        d,
537    })
538}
539
540/// Validates that every supplied 2D point has finite coordinates.
541fn all_points_finite_2d(points: &[Point]) -> bool {
542    points
543        .iter()
544        .all(|p| p.x().is_finite() && p.y().is_finite())
545}
546
547/// Validates that every supplied 3D point has finite coordinates.
548fn all_points_finite_3d(points: &[(f64, f64, f64)]) -> bool {
549    points
550        .iter()
551        .all(|p| p.0.is_finite() && p.1.is_finite() && p.2.is_finite())
552}
553
554/// Robustly fits a 2D line to `points` using RANSAC.
555///
556/// # Errors
557///
558/// Returns [`AlgorithmError::InvalidInput`] when fewer than two points are
559/// supplied or any coordinate is non-finite.
560///
561/// # Behavior
562///
563/// Each iteration samples two distinct points, builds a candidate line, counts
564/// inliers (perpendicular distance `<= inlier_threshold`), and keeps the model
565/// with the most inliers (requiring at least `min_inlier_count`). The required
566/// iteration count is recomputed adaptively after each improvement so clean data
567/// terminates early. The best consensus set is then refined with total least
568/// squares; if the refinement is degenerate, the sampled model is retained.
569pub fn ransac_fit_line(
570    points: &[Point],
571    options: &RansacOptions,
572) -> Result<RansacResult<RansacLineModel>, AlgorithmError> {
573    if points.len() < 2 {
574        return Err(AlgorithmError::InvalidInput(format!(
575            "ransac_fit_line requires at least 2 points, got {}",
576            points.len()
577        )));
578    }
579    if !all_points_finite_2d(points) {
580        return Err(AlgorithmError::InvalidInput(
581            "ransac_fit_line requires all point coordinates to be finite".to_string(),
582        ));
583    }
584
585    let n = points.len();
586    let threshold = options.inlier_threshold;
587    let min_inliers = options.min_inlier_count.max(2);
588    let mut state = options.seed;
589
590    let mut best_model: Option<RansacLineModel> = None;
591    let mut best_inlier_count = 0usize;
592    let mut iterations = 0usize;
593    // Start by requiring the full budget; tighten as better models are found.
594    let mut needed_iterations = options.max_iterations;
595
596    while iterations < needed_iterations && iterations < options.max_iterations {
597        iterations += 1;
598        let (i, j) = sample_two_distinct(n, &mut state);
599        let candidate = match RansacLineModel::from_two_points(points[i].clone(), points[j].clone())
600        {
601            Some(model) => model,
602            None => continue,
603        };
604
605        let mut inlier_count = 0usize;
606        for p in points {
607            if candidate.distance_to(p.clone()) <= threshold {
608                inlier_count += 1;
609            }
610        }
611
612        if inlier_count > best_inlier_count {
613            best_inlier_count = inlier_count;
614            best_model = Some(candidate);
615            let inlier_ratio = inlier_count as f64 / n as f64;
616            needed_iterations = adaptive_iteration_count(
617                inlier_ratio,
618                2,
619                options.confidence,
620                options.max_iterations,
621            );
622        }
623    }
624
625    let converged = best_inlier_count >= min_inliers;
626
627    // Resolve the best model. If nothing met even a single valid sample (all
628    // candidates degenerate), fall back to a model through the first two points
629    // or, failing that, an axis-aligned default so we always return something.
630    let sampled_model = best_model.unwrap_or_else(|| {
631        RansacLineModel::from_two_points(points[0].clone(), points[1].clone()).unwrap_or(
632            RansacLineModel {
633                a: 0.0,
634                b: 1.0,
635                c: -points[0].y(),
636            },
637        )
638    });
639
640    // Recompute the inlier set against the best sampled model.
641    let inliers: Vec<usize> = points
642        .iter()
643        .enumerate()
644        .filter(|(_, p)| sampled_model.distance_to((*p).clone()) <= threshold)
645        .map(|(idx, _)| idx)
646        .collect();
647
648    // Refit via total least squares on the consensus set, falling back to the
649    // sampled model if the covariance is degenerate.
650    let model = match least_squares_line(points, &inliers) {
651        Some(refined) => {
652            // Guard against a refit that loses inliers due to numerical drift:
653            // keep the refit only if it retains at least as many inliers.
654            let refit_inliers = points
655                .iter()
656                .filter(|p| refined.distance_to((*p).clone()) <= threshold)
657                .count();
658            if refit_inliers >= inliers.len() {
659                refined
660            } else {
661                sampled_model
662            }
663        }
664        None => sampled_model,
665    };
666
667    // Final inlier set against the model we are returning.
668    let final_inliers: Vec<usize> = points
669        .iter()
670        .enumerate()
671        .filter(|(_, p)| model.distance_to((*p).clone()) <= threshold)
672        .map(|(idx, _)| idx)
673        .collect();
674
675    Ok(RansacResult {
676        model,
677        inliers: final_inliers,
678        iterations,
679        converged,
680    })
681}
682
683/// Robustly fits a 3D plane to `points` using RANSAC.
684///
685/// # Errors
686///
687/// Returns [`AlgorithmError::InvalidInput`] when fewer than three points are
688/// supplied or any coordinate is non-finite.
689///
690/// # Behavior
691///
692/// Analogous to [`ransac_fit_line`] but samples three distinct points per
693/// iteration and refines with a 3×3 total-least-squares fit (the plane normal is
694/// the least-variance eigenvector of the covariance). `min_inlier_count` is
695/// effectively at least three.
696pub fn ransac_fit_plane(
697    points: &[(f64, f64, f64)],
698    options: &RansacOptions,
699) -> Result<RansacResult<RansacPlaneModel>, AlgorithmError> {
700    if points.len() < 3 {
701        return Err(AlgorithmError::InvalidInput(format!(
702            "ransac_fit_plane requires at least 3 points, got {}",
703            points.len()
704        )));
705    }
706    if !all_points_finite_3d(points) {
707        return Err(AlgorithmError::InvalidInput(
708            "ransac_fit_plane requires all point coordinates to be finite".to_string(),
709        ));
710    }
711
712    let n = points.len();
713    let threshold = options.inlier_threshold;
714    let min_inliers = options.min_inlier_count.max(3);
715    let mut state = options.seed;
716
717    let mut best_model: Option<RansacPlaneModel> = None;
718    let mut best_inlier_count = 0usize;
719    let mut iterations = 0usize;
720    let mut needed_iterations = options.max_iterations;
721
722    while iterations < needed_iterations && iterations < options.max_iterations {
723        iterations += 1;
724        let (i, j, k) = sample_three_distinct(n, &mut state);
725        let candidate = match RansacPlaneModel::from_three_points(points[i], points[j], points[k]) {
726            Some(model) => model,
727            None => continue,
728        };
729
730        let mut inlier_count = 0usize;
731        for &p in points {
732            if candidate.distance_to(p) <= threshold {
733                inlier_count += 1;
734            }
735        }
736
737        if inlier_count > best_inlier_count {
738            best_inlier_count = inlier_count;
739            best_model = Some(candidate);
740            let inlier_ratio = inlier_count as f64 / n as f64;
741            needed_iterations = adaptive_iteration_count(
742                inlier_ratio,
743                3,
744                options.confidence,
745                options.max_iterations,
746            );
747        }
748    }
749
750    let converged = best_inlier_count >= min_inliers;
751
752    let sampled_model = best_model.unwrap_or_else(|| {
753        RansacPlaneModel::from_three_points(points[0], points[1], points[2]).unwrap_or(
754            RansacPlaneModel {
755                a: 0.0,
756                b: 0.0,
757                c: 1.0,
758                d: -points[0].2,
759            },
760        )
761    });
762
763    let inliers: Vec<usize> = points
764        .iter()
765        .enumerate()
766        .filter(|(_, p)| sampled_model.distance_to(**p) <= threshold)
767        .map(|(idx, _)| idx)
768        .collect();
769
770    let model = match least_squares_plane(points, &inliers) {
771        Some(refined) => {
772            let refit_inliers = points
773                .iter()
774                .filter(|p| refined.distance_to(**p) <= threshold)
775                .count();
776            if refit_inliers >= inliers.len() {
777                refined
778            } else {
779                sampled_model
780            }
781        }
782        None => sampled_model,
783    };
784
785    let final_inliers: Vec<usize> = points
786        .iter()
787        .enumerate()
788        .filter(|(_, p)| model.distance_to(**p) <= threshold)
789        .map(|(idx, _)| idx)
790        .collect();
791
792    Ok(RansacResult {
793        model,
794        inliers: final_inliers,
795        iterations,
796        converged,
797    })
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    #[test]
805    fn lcg_is_deterministic_and_advances() {
806        let mut s1 = 12345;
807        let mut s2 = 12345;
808        let a = lcg_next(&mut s1);
809        let b = lcg_next(&mut s2);
810        assert_eq!(a, b);
811        let c = lcg_next(&mut s1);
812        assert_ne!(a, c);
813    }
814
815    #[test]
816    fn lcg_index_in_range() {
817        let mut state = 7;
818        for _ in 0..1000 {
819            let idx = lcg_index(10, &mut state);
820            assert!(idx < 10);
821        }
822    }
823
824    #[test]
825    fn sample_two_distinct_yields_distinct() {
826        let mut state = 99;
827        for _ in 0..1000 {
828            let (i, j) = sample_two_distinct(5, &mut state);
829            assert_ne!(i, j);
830            assert!(i < 5 && j < 5);
831        }
832    }
833
834    #[test]
835    fn sample_three_distinct_yields_distinct() {
836        let mut state = 99;
837        for _ in 0..1000 {
838            let (i, j, k) = sample_three_distinct(6, &mut state);
839            assert_ne!(i, j);
840            assert_ne!(i, k);
841            assert_ne!(j, k);
842            assert!(i < 6 && j < 6 && k < 6);
843        }
844    }
845
846    #[test]
847    fn adaptive_count_guards() {
848        // inlier_ratio >= 1 ⇒ 1
849        assert_eq!(adaptive_iteration_count(1.0, 2, 0.99, 1000), 1);
850        assert_eq!(adaptive_iteration_count(1.5, 2, 0.99, 1000), 1);
851        // inlier_ratio <= 0 ⇒ max
852        assert_eq!(adaptive_iteration_count(0.0, 2, 0.99, 1000), 1000);
853        assert_eq!(adaptive_iteration_count(-0.5, 2, 0.99, 1000), 1000);
854        // non-finite ⇒ max
855        assert_eq!(adaptive_iteration_count(f64::NAN, 2, 0.99, 1000), 1000);
856        assert_eq!(adaptive_iteration_count(f64::INFINITY, 2, 0.99, 1000), 1000);
857        // non-finite confidence ⇒ max
858        assert_eq!(adaptive_iteration_count(0.5, 2, f64::NAN, 1000), 1000);
859        // intermediate ratio ⇒ finite, clamped
860        let mid = adaptive_iteration_count(0.5, 2, 0.99, 1000);
861        assert!((1..=1000).contains(&mid));
862    }
863
864    #[test]
865    fn line_from_coincident_points_is_none() {
866        let p = Point::new(1.0, 1.0);
867        assert!(RansacLineModel::from_two_points(p.clone(), p).is_none());
868    }
869
870    #[test]
871    fn plane_from_collinear_points_is_none() {
872        let p1 = (0.0, 0.0, 0.0);
873        let p2 = (1.0, 1.0, 1.0);
874        let p3 = (2.0, 2.0, 2.0);
875        assert!(RansacPlaneModel::from_three_points(p1, p2, p3).is_none());
876    }
877}