Skip to main content

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