Skip to main content

projective_grid/
homography.rs

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