Skip to main content

projective_grid/geometry/
homography.rs

1//! Projective homography type and estimators.
2//!
3//! Provides the [`Homography`] matrix wrapper, a 4-point direct solver
4//! and an N-point Hartley-normalised DLT estimator, plus
5//! [`HomographyQuality`] for gating degenerate fits.
6
7use crate::float::{lit, Float};
8use nalgebra::{Matrix3, Point2, SMatrix, SVector, Vector3};
9
10/// A 3×3 projective homography matrix.
11///
12/// Maps 2D points between two projective planes: `p_dst ~ H * p_src`.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Homography<F: Float = f32> {
15    /// The raw 3×3 matrix. Defined up to an overall scale; estimators in
16    /// this module normalise it so the bottom-right entry is `1`.
17    pub h: Matrix3<F>,
18}
19
20/// Numerical quality of a homography matrix.
21///
22/// Computed from the SVD of the 3×3 matrix `H`. A near-singular `H` arises
23/// when the source or destination quad has three near-collinear points and
24/// would lead to large re-projection error away from the fitted points.
25///
26/// `condition` is a unitless ratio; the other two are in the same units
27/// as `H`'s entries.
28///
29/// **Diagnostic only — not a scale-stable stability gate.** The
30/// absolute-magnitude fields (`min_singular_value`, `determinant`) depend on
31/// the coordinate scale and translation of the points `H` was fit from, so a
32/// single absolute threshold is not portable across image scales. Use this
33/// struct for inspection and relative comparison, not as a production
34/// degeneracy gate. For production gating prefer a pixel-unit re-projection
35/// residual (as the [`extension`](crate::shared::extension) module does).
36#[non_exhaustive]
37#[derive(Clone, Copy, Debug)]
38pub struct HomographyQuality<F: Float = f32> {
39    /// Largest singular value of `H`.
40    pub max_singular_value: F,
41    /// Smallest singular value of `H`. Approaches zero as the source or
42    /// destination quad degenerates (three collinear points).
43    pub min_singular_value: F,
44    /// Condition number `max_singular_value / min_singular_value`. Healthy
45    /// homographies for calibration targets sit below ~100; values above
46    /// ~10⁴ indicate the fit is dominated by noise on the smallest
47    /// principal direction.
48    pub condition: F,
49    /// Determinant of `H`. Sign indicates orientation; `|det|` decays to
50    /// zero as the map becomes singular.
51    pub determinant: F,
52}
53
54impl<F: Float> HomographyQuality<F> {
55    /// Compute quality from a homography matrix.
56    pub fn from_homography(h: &Homography<F>) -> Self {
57        let svd = h.h.svd(false, false);
58        let mut s_max = F::zero();
59        let mut s_min = F::max_value().unwrap_or_else(|| lit(1e30));
60        for k in 0..3 {
61            let s = svd.singular_values[k];
62            if s > s_max {
63                s_max = s;
64            }
65            if s < s_min {
66                s_min = s;
67            }
68        }
69        let condition = if s_min > F::default_epsilon() {
70            s_max / s_min
71        } else {
72            F::max_value().unwrap_or_else(|| lit(1e30))
73        };
74        let determinant = h.h.determinant();
75        Self {
76            max_singular_value: s_max,
77            min_singular_value: s_min,
78            condition,
79            determinant,
80        }
81    }
82
83    /// `true` when `min_singular_value < threshold`.
84    ///
85    /// **Diagnostic only.** `min_singular_value` scales with the coordinate
86    /// magnitudes of the points `H` was fit from, so a single threshold is not
87    /// portable across image scales — do not use this as a production stability
88    /// gate (prefer a pixel-unit re-projection residual). Retained for opt-in
89    /// conditioning spot-checks and tests.
90    pub fn is_ill_conditioned(&self, min_singular_value_threshold: F) -> bool {
91        self.min_singular_value < min_singular_value_threshold
92    }
93}
94
95impl<F: Float> Homography<F> {
96    /// Wrap an existing 3×3 matrix as a homography. The matrix is taken
97    /// as-is; no normalisation is applied.
98    pub fn new(h: Matrix3<F>) -> Self {
99        Self { h }
100    }
101
102    /// Build a homography from a row-major `[[row0], [row1], [row2]]` array.
103    pub fn from_array(rows: [[F; 3]; 3]) -> Self {
104        Self::new(Matrix3::from_row_slice(&[
105            rows[0][0], rows[0][1], rows[0][2], rows[1][0], rows[1][1], rows[1][2], rows[2][0],
106            rows[2][1], rows[2][2],
107        ]))
108    }
109
110    /// Return the matrix as a row-major `[[row0], [row1], [row2]]` array.
111    pub fn to_array(&self) -> [[F; 3]; 3] {
112        [
113            [self.h[(0, 0)], self.h[(0, 1)], self.h[(0, 2)]],
114            [self.h[(1, 0)], self.h[(1, 1)], self.h[(1, 2)]],
115            [self.h[(2, 0)], self.h[(2, 1)], self.h[(2, 2)]],
116        ]
117    }
118
119    /// A homography backed by the all-zeros matrix. Not invertible — used
120    /// only as a placeholder before a real estimate is available.
121    pub fn zero() -> Self {
122        Self {
123            h: Matrix3::zeros(),
124        }
125    }
126
127    /// Apply the homography to a 2D point.
128    #[inline]
129    pub fn apply(&self, p: Point2<F>) -> Point2<F> {
130        let v = self.h * Vector3::new(p.x, p.y, F::one());
131        let w = v[2];
132        Point2::new(v[0] / w, v[1] / w)
133    }
134
135    /// Compute the inverse homography, if the matrix is invertible.
136    pub fn inverse(&self) -> Option<Self> {
137        self.h.try_inverse().map(Self::new)
138    }
139}
140
141// ---- Hartley normalization ----
142
143fn hartley_normalization<F: Float>(cx: F, cy: F, mean_dist: F) -> Matrix3<F> {
144    let s = if mean_dist > lit(1e-12) {
145        lit::<F>(2.0).sqrt() / mean_dist
146    } else {
147        F::one()
148    };
149
150    Matrix3::new(
151        s,
152        F::zero(),
153        -s * cx,
154        F::zero(),
155        s,
156        -s * cy,
157        F::zero(),
158        F::zero(),
159        F::one(),
160    )
161}
162
163fn normalize_points<F: Float>(pts: &[Point2<F>]) -> (Vec<Point2<F>>, Matrix3<F>) {
164    let n: F = lit(pts.len() as f32);
165    let mut cx = F::zero();
166    let mut cy = F::zero();
167    for p in pts {
168        cx += p.x;
169        cy += p.y;
170    }
171    cx /= n;
172    cy /= n;
173
174    let mut mean_dist = F::zero();
175    for p in pts {
176        let dx = p.x - cx;
177        let dy = p.y - cy;
178        mean_dist += (dx * dx + dy * dy).sqrt();
179    }
180    mean_dist /= n;
181
182    let t = hartley_normalization(cx, cy, mean_dist);
183
184    let mut out = Vec::with_capacity(pts.len());
185    for p in pts {
186        let v = t * Vector3::new(p.x, p.y, F::one());
187        out.push(Point2::new(v[0], v[1]));
188    }
189    (out, t)
190}
191
192fn normalize_points4<F: Float>(pts: &[Point2<F>; 4]) -> ([Point2<F>; 4], Matrix3<F>) {
193    let n: F = lit(4.0);
194    let mut cx = F::zero();
195    let mut cy = F::zero();
196    for p in pts {
197        cx += p.x;
198        cy += p.y;
199    }
200    cx /= n;
201    cy /= n;
202
203    let mut mean_dist = F::zero();
204    for p in pts {
205        let dx = p.x - cx;
206        let dy = p.y - cy;
207        mean_dist += (dx * dx + dy * dy).sqrt();
208    }
209    mean_dist /= n;
210
211    let t = hartley_normalization(cx, cy, mean_dist);
212
213    let mut out = [Point2::new(F::zero(), F::zero()); 4];
214    for (i, p) in pts.iter().enumerate() {
215        let v = t * Vector3::new(p.x, p.y, F::one());
216        out[i] = Point2::new(v[0], v[1]);
217    }
218
219    (out, t)
220}
221
222fn normalize_homography<F: Float>(h: Matrix3<F>) -> Option<Matrix3<F>> {
223    let s = h[(2, 2)];
224    if s.abs() < lit(1e-12) {
225        return None;
226    }
227    Some(h / s)
228}
229
230fn denormalize_homography<F: Float>(
231    hn: Matrix3<F>,
232    t_src: Matrix3<F>,
233    t_dst: Matrix3<F>,
234) -> Option<Matrix3<F>> {
235    let t_dst_inv = t_dst.try_inverse()?;
236    Some(t_dst_inv * hn * t_src)
237}
238
239/// Estimate H such that `p_dst ~ H * p_src` from N >= 4 point correspondences,
240/// returning the matrix together with its [`HomographyQuality`].
241///
242/// Wraps [`estimate_homography`]; callers that already discard the quality
243/// data can keep using that. Use this entry point to reject cells whose
244/// minimum singular value falls below a tolerance, or to log conditioning
245/// information for diagnostic surfaces.
246pub fn estimate_homography_with_quality<F: Float>(
247    src_pts: &[Point2<F>],
248    dst_pts: &[Point2<F>],
249) -> Option<(Homography<F>, HomographyQuality<F>)> {
250    let h = estimate_homography(src_pts, dst_pts)?;
251    let q = HomographyQuality::from_homography(&h);
252    Some((h, q))
253}
254
255/// 4-point variant of [`estimate_homography_with_quality`].
256pub fn homography_from_4pt_with_quality<F: Float>(
257    src: &[Point2<F>; 4],
258    dst: &[Point2<F>; 4],
259) -> Option<(Homography<F>, HomographyQuality<F>)> {
260    let h = homography_from_4pt(src, dst)?;
261    let q = HomographyQuality::from_homography(&h);
262    Some((h, q))
263}
264
265/// Estimate H such that `p_dst ~ H * p_src` from N >= 4 point correspondences.
266///
267/// Uses Hartley normalization + DLT for N > 4 and a direct 4-point solver for N == 4.
268pub fn estimate_homography<F: Float>(
269    src_pts: &[Point2<F>],
270    dst_pts: &[Point2<F>],
271) -> Option<Homography<F>> {
272    if src_pts.len() != dst_pts.len() || src_pts.len() < 4 {
273        return None;
274    }
275
276    if src_pts.len() == 4 {
277        let src: &[Point2<F>; 4] = src_pts.try_into().ok()?;
278        let dst: &[Point2<F>; 4] = dst_pts.try_into().ok()?;
279        return homography_from_4pt(src, dst);
280    }
281
282    let (r, tr) = normalize_points(src_pts);
283    let (im, ti) = normalize_points(dst_pts);
284
285    let n = src_pts.len();
286    let zero = F::zero();
287    let neg_one = -F::one();
288
289    // Accumulate Aᵀ A directly into a stack 9×9 without ever
290    // materialising the (2N × 9) matrix A. For correspondence
291    // `(x, y) ↦ (u, v)` the two DLT rows are
292    //   row₂ₖ   = [-x, -y, -1,  0,  0,  0, ux, uy, u]
293    //   row₂ₖ₊₁ = [ 0,  0,  0, -x, -y, -1, vx, vy, v]
294    // and Aᵀ A = Σₖ (rowᵀ row) summed over both rows of each pair.
295    let mut m: SMatrix<F, 9, 9> = SMatrix::zeros();
296    for k in 0..n {
297        let x = r[k].x;
298        let y = r[k].y;
299        let u = im[k].x;
300        let v = im[k].y;
301
302        let row1 = SVector::<F, 9>::from_column_slice(&[
303            -x,
304            -y,
305            neg_one,
306            zero,
307            zero,
308            zero,
309            u * x,
310            u * y,
311            u,
312        ]);
313        let row2 = SVector::<F, 9>::from_column_slice(&[
314            zero,
315            zero,
316            zero,
317            -x,
318            -y,
319            neg_one,
320            v * x,
321            v * y,
322            v,
323        ]);
324        m += row1 * row1.transpose();
325        m += row2 * row2.transpose();
326    }
327
328    // The right singular vector of A for σ_min is the eigenvector of
329    // Aᵀ A for the smallest eigenvalue: A = U Σ Vᵀ ⇒ Aᵀ A = V Σ² Vᵀ.
330    // Hartley normalisation keeps cond(A) ≲ 10³, so cond(Aᵀ A) ≲ 10⁶ —
331    // comfortably within f32 precision. Sign is unconstrained here but
332    // pinned downstream by `normalize_homography` dividing by h[(2,2)].
333    let eig = m.symmetric_eigen();
334    let mut min_idx = 0usize;
335    let mut min_val = eig.eigenvalues[0];
336    for k in 1..9 {
337        if eig.eigenvalues[k] < min_val {
338            min_val = eig.eigenvalues[k];
339            min_idx = k;
340        }
341    }
342    let h = eig.eigenvectors.column(min_idx);
343
344    let hn = Matrix3::<F>::from_row_slice(&[h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]]);
345
346    let h_den = denormalize_homography(hn, tr, ti)?;
347    let h_den = normalize_homography(h_den)?;
348
349    Some(Homography::new(h_den))
350}
351
352/// Compute H from exactly 4 point correspondences: `dst ~ H * src`.
353///
354/// Uses Hartley normalization for numerical stability.
355pub fn homography_from_4pt<F: Float>(
356    src: &[Point2<F>; 4],
357    dst: &[Point2<F>; 4],
358) -> Option<Homography<F>> {
359    let (src_n, t_src) = normalize_points4(src);
360    let (dst_n, t_dst) = normalize_points4(dst);
361
362    let mut a = SMatrix::<F, 8, 8>::zeros();
363    let mut b = SVector::<F, 8>::zeros();
364
365    for k in 0..4 {
366        let x = src_n[k].x;
367        let y = src_n[k].y;
368        let u = dst_n[k].x;
369        let v = dst_n[k].y;
370
371        let r0 = 2 * k;
372        a[(r0, 0)] = x;
373        a[(r0, 1)] = y;
374        a[(r0, 2)] = F::one();
375        a[(r0, 6)] = -u * x;
376        a[(r0, 7)] = -u * y;
377        b[r0] = u;
378
379        let r1 = 2 * k + 1;
380        a[(r1, 3)] = x;
381        a[(r1, 4)] = y;
382        a[(r1, 5)] = F::one();
383        a[(r1, 6)] = -v * x;
384        a[(r1, 7)] = -v * y;
385        b[r1] = v;
386    }
387
388    let x = a.lu().solve(&b)?;
389
390    let hn = Matrix3::<F>::new(
391        x[0],
392        x[1],
393        x[2], //
394        x[3],
395        x[4],
396        x[5], //
397        x[6],
398        x[7],
399        F::one(),
400    );
401
402    let h_den = denormalize_homography(hn, t_src, t_dst)?;
403    let h_den = normalize_homography(h_den)?;
404
405    Some(Homography::new(h_den))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn assert_close(a: Point2<f32>, b: Point2<f32>, tol: f32) {
413        let dx = (a.x - b.x).abs();
414        let dy = (a.y - b.y).abs();
415        assert!(
416            dx < tol && dy < tol,
417            "expected ({:.6},{:.6}) ~ ({:.6},{:.6}) within {}",
418            a.x,
419            a.y,
420            b.x,
421            b.y,
422            tol
423        );
424    }
425
426    #[test]
427    fn inverse_round_trips_points() {
428        let h = Homography::new(Matrix3::new(
429            1.2, 0.1, 5.0, //
430            -0.05, 0.9, 3.0, //
431            0.001, 0.0005, 1.0,
432        ));
433        let inv = h.inverse().expect("invertible");
434
435        for p in [
436            Point2::new(0.0_f32, 0.0),
437            Point2::new(50.0_f32, -20.0),
438            Point2::new(320.0_f32, 200.0),
439        ] {
440            let q = h.apply(p);
441            let back = inv.apply(q);
442            assert_close(back, p, 1e-3);
443        }
444    }
445
446    #[test]
447    fn four_point_specialization_recovers_h() {
448        let ground_truth = Homography::new(Matrix3::new(
449            0.8, 0.05, 120.0, //
450            -0.02, 1.1, 80.0, //
451            0.0009, -0.0004, 1.0,
452        ));
453
454        let rect = [
455            Point2::new(0.0_f32, 0.0),
456            Point2::new(180.0_f32, 0.0),
457            Point2::new(180.0_f32, 130.0),
458            Point2::new(0.0_f32, 130.0),
459        ];
460        let dst = rect.map(|p| ground_truth.apply(p));
461
462        let recovered = homography_from_4pt(&rect, &dst).expect("recoverable");
463
464        for p in [
465            Point2::new(0.0_f32, 0.0),
466            Point2::new(60.0, 40.0),
467            Point2::new(150.0, 120.0),
468        ] {
469            assert_close(recovered.apply(p), ground_truth.apply(p), 1e-3);
470        }
471    }
472
473    #[test]
474    fn dlt_handles_overdetermined_case() {
475        let ground_truth = Homography::new(Matrix3::new(
476            1.0, 0.2, 12.0, //
477            -0.1, 0.9, 6.0, //
478            0.0006, 0.0004, 1.0,
479        ));
480
481        let rect: Vec<Point2<f32>> = (0..3)
482            .flat_map(|y| (0..3).map(move |x| Point2::new(x as f32 * 40.0, y as f32 * 50.0)))
483            .collect();
484        let img: Vec<Point2<f32>> = rect.iter().map(|&p| ground_truth.apply(p)).collect();
485
486        let estimated = estimate_homography(&rect, &img).expect("estimate");
487        for p in [
488            Point2::new(0.0_f32, 0.0),
489            Point2::new(60.0, 40.0),
490            Point2::new(80.0, 90.0),
491            Point2::new(80.0, 100.0),
492        ] {
493            assert_close(estimated.apply(p), ground_truth.apply(p), 1e-3);
494        }
495    }
496
497    #[test]
498    fn mismatched_input_lengths_fail() {
499        let rect = [Point2::new(0.0_f32, 0.0); 4];
500        let img = [Point2::new(1.0_f32, 1.0); 3];
501        assert!(estimate_homography(&rect, &img).is_none());
502    }
503
504    #[test]
505    fn quality_reports_finite_metrics_for_clean_homography() {
506        let rect = [
507            Point2::new(0.0_f32, 0.0),
508            Point2::new(100.0, 0.0),
509            Point2::new(100.0, 100.0),
510            Point2::new(0.0, 100.0),
511        ];
512        // Mild rotation + translation — well-defined homography.
513        let dst = [
514            Point2::new(50.0, 50.0),
515            Point2::new(150.0, 60.0),
516            Point2::new(140.0, 160.0),
517            Point2::new(40.0, 150.0),
518        ];
519        let (_, q) = homography_from_4pt_with_quality(&rect, &dst).expect("h");
520        // All metrics are finite and non-degenerate for a clean fit.
521        assert!(q.max_singular_value.is_finite() && q.max_singular_value > 0.0);
522        assert!(q.min_singular_value > 0.0);
523        assert!(q.condition.is_finite());
524        assert!(q.determinant.abs() > 1e-3);
525        // For a clean homography the smallest singular value is at the same
526        // order as the determinant of the upper-left 2×2 — i.e., not near
527        // machine epsilon.
528        assert!(
529            q.min_singular_value > 1e-2,
530            "min_sv {} unexpectedly tiny on a clean fit",
531            q.min_singular_value
532        );
533    }
534
535    #[test]
536    fn quality_min_sv_separates_clean_from_degenerate() {
537        // The min singular value of H — and the ratio σ_min / σ_max — both
538        // collapse when the destination quad has three near-collinear
539        // points. Test on a unit-scale source so absolute and relative
540        // metrics tell the same story.
541        let rect = [
542            Point2::new(0.0_f32, 0.0),
543            Point2::new(1.0, 0.0),
544            Point2::new(1.0, 1.0),
545            Point2::new(0.0, 1.0),
546        ];
547        let clean_dst = [
548            Point2::new(0.0_f32, 0.0),
549            Point2::new(2.0, 0.0),
550            Point2::new(2.0, 2.0),
551            Point2::new(0.0, 2.0),
552        ];
553        let degen_dst = [
554            Point2::new(0.0_f32, 0.0),
555            Point2::new(1.0, 0.0),
556            Point2::new(1.0, 1e-6),
557            Point2::new(0.0, 1e-6),
558        ];
559        let (_, q_clean) = homography_from_4pt_with_quality(&rect, &clean_dst).expect("clean");
560        let (_, q_degen) = homography_from_4pt_with_quality(&rect, &degen_dst).expect("degen");
561
562        assert!(
563            q_clean.min_singular_value > q_degen.min_singular_value * 100.0,
564            "clean min_sv {} must be much larger than degenerate {}",
565            q_clean.min_singular_value,
566            q_degen.min_singular_value
567        );
568        // Reciprocal condition (σ_min / σ_max) is the scale-invariant flag.
569        let recip_clean = q_clean.min_singular_value / q_clean.max_singular_value;
570        let recip_degen = q_degen.min_singular_value / q_degen.max_singular_value;
571        assert!(
572            recip_clean > 0.1,
573            "clean recip_cond {recip_clean} too small"
574        );
575        assert!(
576            recip_degen < 1e-3,
577            "degenerate recip_cond {recip_degen} too large"
578        );
579    }
580
581    #[test]
582    fn is_ill_conditioned_threshold_works() {
583        let rect = [
584            Point2::new(0.0_f32, 0.0),
585            Point2::new(1.0, 0.0),
586            Point2::new(1.0, 1.0),
587            Point2::new(0.0, 1.0),
588        ];
589        let degen_dst = [
590            Point2::new(0.0_f32, 0.0),
591            Point2::new(1.0, 0.0),
592            Point2::new(1.0, 1e-6),
593            Point2::new(0.0, 1e-6),
594        ];
595        let (_, q) = homography_from_4pt_with_quality(&rect, &degen_dst).expect("h");
596        assert!(q.is_ill_conditioned(1e-3));
597        assert!(!q.is_ill_conditioned(1e-12));
598    }
599
600    #[test]
601    fn estimate_with_quality_matches_direct_call() {
602        let ground_truth: Homography<f32> = Homography::new(Matrix3::new(
603            1.0, 0.2, 12.0, //
604            -0.1, 0.9, 6.0, //
605            0.0006, 0.0004, 1.0,
606        ));
607        let rect: Vec<Point2<f32>> = (0..3)
608            .flat_map(|y| (0..3).map(move |x| Point2::new(x as f32 * 40.0, y as f32 * 50.0)))
609            .collect();
610        let img: Vec<Point2<f32>> = rect.iter().map(|&p| ground_truth.apply(p)).collect();
611
612        let h = estimate_homography(&rect, &img).expect("h");
613        let (h_with_q, _) = estimate_homography_with_quality(&rect, &img).expect("h+q");
614        for r in 0..3 {
615            for c in 0..3 {
616                assert!((h.h[(r, c)] - h_with_q.h[(r, c)]).abs() < 1e-6);
617            }
618        }
619    }
620
621    #[test]
622    fn f64_round_trip() {
623        let h: Homography<f64> = Homography::new(Matrix3::new(
624            1.2, 0.1, 5.0, //
625            -0.05, 0.9, 3.0, //
626            0.001, 0.0005, 1.0,
627        ));
628        let inv = h.inverse().expect("invertible");
629
630        for p in [
631            Point2::new(0.0_f64, 0.0),
632            Point2::new(50.0_f64, -20.0),
633            Point2::new(320.0_f64, 200.0),
634        ] {
635            let q = h.apply(p);
636            let back = inv.apply(q);
637            assert!((back.x - p.x).abs() < 1e-10);
638            assert!((back.y - p.y).abs() < 1e-10);
639        }
640    }
641
642    #[test]
643    fn f64_estimate_homography() {
644        let ground_truth: Homography<f64> = Homography::new(Matrix3::new(
645            1.0, 0.2, 12.0, //
646            -0.1, 0.9, 6.0, //
647            0.0006, 0.0004, 1.0,
648        ));
649
650        let rect: Vec<Point2<f64>> = (0..3)
651            .flat_map(|y| (0..3).map(move |x| Point2::new(x as f64 * 40.0, y as f64 * 50.0)))
652            .collect();
653        let img: Vec<Point2<f64>> = rect.iter().map(|&p| ground_truth.apply(p)).collect();
654
655        let estimated = estimate_homography(&rect, &img).expect("estimate");
656        for p in [
657            Point2::new(0.0_f64, 0.0),
658            Point2::new(60.0, 40.0),
659            Point2::new(80.0, 90.0),
660        ] {
661            let a = estimated.apply(p);
662            let b = ground_truth.apply(p);
663            assert!((a.x - b.x).abs() < 1e-8);
664            assert!((a.y - b.y).abs() < 1e-8);
665        }
666    }
667
668    /// Reference DLT path matching the SVD-based implementation that
669    /// existed before the normal-equations + 9×9 sym-eig rewrite. Kept
670    /// inline so the cross-check test below has a frozen baseline that
671    /// the production path can drift away from only within the
672    /// numerical-equivalence contract.
673    fn dlt_via_svd_reference(
674        src_pts: &[Point2<f32>],
675        dst_pts: &[Point2<f32>],
676    ) -> Option<Homography<f32>> {
677        if src_pts.len() != dst_pts.len() || src_pts.len() < 4 {
678            return None;
679        }
680        let (r, tr) = normalize_points(src_pts);
681        let (im, ti) = normalize_points(dst_pts);
682
683        let n = src_pts.len();
684        let rows = 2 * n;
685        let mut a = nalgebra::DMatrix::<f32>::zeros(rows, 9);
686        for k in 0..n {
687            let x = r[k].x;
688            let y = r[k].y;
689            let u = im[k].x;
690            let v = im[k].y;
691            a[(2 * k, 0)] = -x;
692            a[(2 * k, 1)] = -y;
693            a[(2 * k, 2)] = -1.0;
694            a[(2 * k, 6)] = u * x;
695            a[(2 * k, 7)] = u * y;
696            a[(2 * k, 8)] = u;
697
698            a[(2 * k + 1, 3)] = -x;
699            a[(2 * k + 1, 4)] = -y;
700            a[(2 * k + 1, 5)] = -1.0;
701            a[(2 * k + 1, 6)] = v * x;
702            a[(2 * k + 1, 7)] = v * y;
703            a[(2 * k + 1, 8)] = v;
704        }
705        let svd = a.svd(true, true);
706        let vt = svd.v_t?;
707        let last = vt.nrows().checked_sub(1)?;
708        let h = vt.row(last);
709        let hn =
710            Matrix3::<f32>::from_row_slice(&[h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]]);
711        let h_den = denormalize_homography(hn, tr, ti)?;
712        let h_den = normalize_homography(h_den)?;
713        Some(Homography::new(h_den))
714    }
715
716    /// Tiny deterministic xorshift32 PRNG so the random-battery test
717    /// is reproducible without pulling in a `rand` dev-dependency for
718    /// one helper.
719    struct XorShift32(u32);
720    impl XorShift32 {
721        fn new(seed: u32) -> Self {
722            Self(seed.max(1))
723        }
724        fn next_u32(&mut self) -> u32 {
725            let mut x = self.0;
726            x ^= x << 13;
727            x ^= x >> 17;
728            x ^= x << 5;
729            self.0 = x;
730            x
731        }
732        /// Uniform draw in `(-1, 1)`.
733        fn unit(&mut self) -> f32 {
734            (self.next_u32() as f32 / u32::MAX as f32) * 2.0 - 1.0
735        }
736    }
737
738    #[test]
739    fn dlt_matches_old_svd_path_on_random_battery() {
740        // Forward-error contract: for a random battery of well-
741        // conditioned ground-truth homographies, the new
742        // normal-equations + 9×9 sym-eig path produces warped points
743        // that agree with the SVD reference to ≤ 0.01 px on a 100-px
744        // scale (equivalent to ~1e-4 relative). Both paths likewise
745        // agree with the ground-truth H to ≤ 0.01 px. Comparing in
746        // pixel-domain is the contract that actually matters
747        // downstream: H is consumed via `apply(...)` to predict cell
748        // positions, never read entry-by-entry.
749        //
750        // Per-entry relative error on `H` itself can drift up to ~1e-3
751        // because Hartley-normalised cond(A) ≈ 10²-10³ and the
752        // normal-equations route squares it (cond(AᵀA) ≈ 10⁴-10⁶);
753        // in f32 the resulting backward error on the smallest
754        // eigenvector can hit 1e-3 on individual entries. The
755        // pixel-domain test absorbs the gauge-like cancellation that
756        // makes per-entry comparison overly pessimistic.
757        let mut rng = XorShift32::new(42);
758
759        let mut max_fwd_err_new = 0.0f32;
760        let mut max_fwd_err_ref = 0.0f32;
761        let mut max_pair_err = 0.0f32;
762        let mut sample_count = 0usize;
763
764        for _ in 0..1000 {
765            // Random ground-truth H with mild perspective.
766            let gt = Homography::new(Matrix3::new(
767                1.0 + 0.5 * rng.unit(),
768                0.2 * rng.unit(),
769                50.0 * rng.unit(),
770                0.2 * rng.unit(),
771                1.0 + 0.5 * rng.unit(),
772                50.0 * rng.unit(),
773                0.001 * rng.unit(),
774                0.001 * rng.unit(),
775                1.0,
776            ));
777            // 12 source points uniformly on ~cell-grid scale.
778            let src: Vec<Point2<f32>> = (0..12)
779                .map(|_| Point2::new(100.0 * rng.unit(), 100.0 * rng.unit()))
780                .collect();
781            let dst: Vec<Point2<f32>> = src.iter().map(|&p| gt.apply(p)).collect();
782
783            let Some(new_h) = estimate_homography(&src, &dst) else {
784                continue;
785            };
786            let Some(ref_h) = dlt_via_svd_reference(&src, &dst) else {
787                continue;
788            };
789
790            // Forward errors against ground truth at the source points.
791            for &p in &src {
792                let new_p = new_h.apply(p);
793                let ref_p = ref_h.apply(p);
794                let gt_p = gt.apply(p);
795                let new_err = ((new_p.x - gt_p.x).powi(2) + (new_p.y - gt_p.y).powi(2)).sqrt();
796                let ref_err = ((ref_p.x - gt_p.x).powi(2) + (ref_p.y - gt_p.y).powi(2)).sqrt();
797                let pair_err = ((new_p.x - ref_p.x).powi(2) + (new_p.y - ref_p.y).powi(2)).sqrt();
798                if new_err > max_fwd_err_new {
799                    max_fwd_err_new = new_err;
800                }
801                if ref_err > max_fwd_err_ref {
802                    max_fwd_err_ref = ref_err;
803                }
804                if pair_err > max_pair_err {
805                    max_pair_err = pair_err;
806                }
807            }
808
809            sample_count += 1;
810        }
811
812        assert!(
813            sample_count > 900,
814            "expected most random samples to be valid, got {sample_count}"
815        );
816        // Both paths agree with ground truth to well below 0.01 px on
817        // a 100-px scale.
818        assert!(
819            max_fwd_err_new < 1e-2,
820            "new path max forward error {max_fwd_err_new} px exceeds 1e-2"
821        );
822        assert!(
823            max_fwd_err_ref < 1e-2,
824            "reference SVD max forward error {max_fwd_err_ref} px exceeds 1e-2"
825        );
826        // New vs reference: similarly bounded since both are within
827        // their backward-error budget against gt.
828        assert!(
829            max_pair_err < 1e-2,
830            "new vs reference max pixel divergence {max_pair_err} px exceeds 1e-2"
831        );
832    }
833}