calib_targets_core/
homography.rs

1use crate::{sample_bilinear_u8, GrayImage, GrayImageView};
2use nalgebra::{DMatrix, Matrix3, Point2, SMatrix, SVector, Vector3};
3
4#[derive(Clone, Copy, Debug, PartialEq)]
5pub struct Homography {
6    pub h: Matrix3<f64>,
7}
8
9impl Homography {
10    pub fn new(h: Matrix3<f64>) -> Self {
11        Self { h }
12    }
13
14    pub fn from_array(rows: [[f64; 3]; 3]) -> Self {
15        Self::new(Matrix3::from_row_slice(&[
16            rows[0][0], rows[0][1], rows[0][2], rows[1][0], rows[1][1], rows[1][2], rows[2][0],
17            rows[2][1], rows[2][2],
18        ]))
19    }
20
21    pub fn to_array(&self) -> [[f64; 3]; 3] {
22        [
23            [self.h[(0, 0)], self.h[(0, 1)], self.h[(0, 2)]],
24            [self.h[(1, 0)], self.h[(1, 1)], self.h[(1, 2)]],
25            [self.h[(2, 0)], self.h[(2, 1)], self.h[(2, 2)]],
26        ]
27    }
28
29    pub fn zero() -> Self {
30        Self {
31            h: Matrix3::zeros(),
32        }
33    }
34
35    #[inline]
36    pub fn apply(&self, p: Point2<f32>) -> Point2<f32> {
37        let v = self.h * Vector3::new(p.x as f64, p.y as f64, 1.0);
38        let w = v[2];
39        Point2::new((v[0] / w) as f32, (v[1] / w) as f32)
40    }
41
42    pub fn inverse(&self) -> Option<Self> {
43        self.h.try_inverse().map(Self::new)
44    }
45}
46
47fn hartley_normalization(cx: f64, cy: f64, mean_dist: f64) -> Matrix3<f64> {
48    let s = if mean_dist > 1e-12 {
49        (2.0_f64).sqrt() / mean_dist
50    } else {
51        1.0
52    };
53
54    Matrix3::<f64>::new(s, 0.0, -s * cx, 0.0, s, -s * cy, 0.0, 0.0, 1.0)
55}
56
57fn normalize_points(pts: &[Point2<f32>]) -> (Vec<Point2<f64>>, Matrix3<f64>) {
58    // Hartley normalization: translate to centroid, scale so mean distance = sqrt(2)
59    let n = pts.len() as f64;
60    let mut cx = 0.0;
61    let mut cy = 0.0;
62    for p in pts {
63        cx += p.x as f64;
64        cy += p.y as f64;
65    }
66    cx /= n;
67    cy /= n;
68
69    let mut mean_dist = 0.0;
70    for p in pts {
71        let dx = p.x as f64 - cx;
72        let dy = p.y as f64 - cy;
73        mean_dist += (dx * dx + dy * dy).sqrt();
74    }
75    mean_dist /= n;
76
77    let t = hartley_normalization(cx, cy, mean_dist);
78
79    let mut out = Vec::with_capacity(pts.len());
80    for p in pts {
81        let v = t * Vector3::new(p.x as f64, p.y as f64, 1.0);
82        out.push(Point2::new(v[0], v[1]));
83    }
84    (out, t)
85}
86
87fn normalize_points4(pts: &[Point2<f32>; 4]) -> ([Point2<f64>; 4], Matrix3<f64>) {
88    let n = 4.0_f64;
89    let mut cx = 0.0_f64;
90    let mut cy = 0.0_f64;
91    for p in pts {
92        cx += p.x as f64;
93        cy += p.y as f64;
94    }
95    cx /= n;
96    cy /= n;
97
98    let mut mean_dist = 0.0_f64;
99    for p in pts {
100        let dx = p.x as f64 - cx;
101        let dy = p.y as f64 - cy;
102        mean_dist += (dx * dx + dy * dy).sqrt();
103    }
104    mean_dist /= n;
105
106    let t = hartley_normalization(cx, cy, mean_dist);
107
108    let mut out = [Point2::new(0.0_f64, 0.0_f64); 4];
109    for (i, p) in pts.iter().enumerate() {
110        let v = t * Vector3::new(p.x as f64, p.y as f64, 1.0);
111        out[i] = Point2::new(v[0], v[1]);
112    }
113
114    (out, t)
115}
116
117fn normalize_homography(h: Matrix3<f64>) -> Option<Matrix3<f64>> {
118    let s = h[(2, 2)];
119    if s.abs() < 1e-12 {
120        return None;
121    }
122    Some(h / s)
123}
124
125fn denormalize_homography(
126    hn: Matrix3<f64>,
127    t_src: Matrix3<f64>,
128    t_dst: Matrix3<f64>,
129) -> Option<Matrix3<f64>> {
130    let t_dst_inv = t_dst.try_inverse()?;
131    Some(t_dst_inv * hn * t_src)
132}
133
134/// Estimate H such that:  p_img ~ H * p_rect
135pub fn estimate_homography_rect_to_img(
136    rect_pts: &[Point2<f32>],
137    img_pts: &[Point2<f32>],
138) -> Option<Homography> {
139    if rect_pts.len() != img_pts.len() || rect_pts.len() < 4 {
140        return None;
141    }
142
143    if rect_pts.len() == 4 {
144        let src: &[Point2<f32>; 4] = rect_pts.try_into().ok()?;
145        let dst: &[Point2<f32>; 4] = img_pts.try_into().ok()?;
146        return homography_from_4pt(src, dst);
147    }
148
149    let (r, tr) = normalize_points(rect_pts);
150    let (i, ti) = normalize_points(img_pts);
151
152    // Build A (2N x 9)
153    let n = rect_pts.len();
154    let rows = 2 * n;
155    let mut a = DMatrix::<f64>::zeros(rows, 9);
156
157    for k in 0..n {
158        let x = r[k].x;
159        let y = r[k].y;
160        let u = i[k].x;
161        let v = i[k].y;
162
163        // [ -x -y -1   0  0  0   u*x u*y u ]
164        a[(2 * k, 0)] = -x;
165        a[(2 * k, 1)] = -y;
166        a[(2 * k, 2)] = -1.0;
167        a[(2 * k, 6)] = u * x;
168        a[(2 * k, 7)] = u * y;
169        a[(2 * k, 8)] = u;
170
171        // [ 0  0  0  -x -y -1   v*x v*y v ]
172        a[(2 * k + 1, 3)] = -x;
173        a[(2 * k + 1, 4)] = -y;
174        a[(2 * k + 1, 5)] = -1.0;
175        a[(2 * k + 1, 6)] = v * x;
176        a[(2 * k + 1, 7)] = v * y;
177        a[(2 * k + 1, 8)] = v;
178    }
179
180    // Solve Ah = 0 -> h is right singular vector with smallest singular value
181    let svd = a.svd(true, true);
182    let vt = svd.v_t?;
183    let last = vt.nrows().checked_sub(1)?;
184    let h = vt.row(last); // last row of V^T = last column of V
185
186    let hn =
187        Matrix3::<f64>::from_row_slice(&[h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]]);
188
189    // Denormalize: H = Ti^{-1} * Hn * Tr
190    let h_den = denormalize_homography(hn, tr, ti)?;
191    let h_den = normalize_homography(h_den)?;
192
193    Some(Homography::new(h_den))
194}
195
196/// Compute H such that: dst ~ H * src (projective), using 4 point correspondences.
197/// - `src`: points in "patch/cell" coords
198/// - `dst`: points in image coords
199///
200/// Corner order must be consistent between `src` and `dst`.
201pub fn homography_from_4pt(src: &[Point2<f32>; 4], dst: &[Point2<f32>; 4]) -> Option<Homography> {
202    // Unknowns: [h11 h12 h13 h21 h22 h23 h31 h32], with h33 = 1
203    // For each correspondence (x,y)->(u,v):
204    // h11 x + h12 y + h13 - u h31 x - u h32 y = u
205    // h21 x + h22 y + h23 - v h31 x - v h32 y = v
206    let (src_n, t_src) = normalize_points4(src);
207    let (dst_n, t_dst) = normalize_points4(dst);
208
209    let mut a = SMatrix::<f64, 8, 8>::zeros();
210    let mut b = SVector::<f64, 8>::zeros();
211
212    for k in 0..4 {
213        let x = src_n[k].x;
214        let y = src_n[k].y;
215        let u = dst_n[k].x;
216        let v = dst_n[k].y;
217
218        // row 2k
219        let r0 = 2 * k;
220        a[(r0, 0)] = x;
221        a[(r0, 1)] = y;
222        a[(r0, 2)] = 1.0;
223        a[(r0, 6)] = -u * x;
224        a[(r0, 7)] = -u * y;
225        b[r0] = u;
226
227        // row 2k+1
228        let r1 = 2 * k + 1;
229        a[(r1, 3)] = x;
230        a[(r1, 4)] = y;
231        a[(r1, 5)] = 1.0;
232        a[(r1, 6)] = -v * x;
233        a[(r1, 7)] = -v * y;
234        b[r1] = v;
235    }
236
237    let x = a.lu().solve(&b)?;
238
239    let hn = Matrix3::<f64>::new(
240        x[0], x[1], x[2], //
241        x[3], x[4], x[5], //
242        x[6], x[7], 1.0,
243    );
244
245    let h_den = denormalize_homography(hn, t_src, t_dst)?;
246    let h_den = normalize_homography(h_den)?;
247
248    Some(Homography::new(h_den))
249}
250
251/// Warp into rectified image: for each dst pixel, map to src via H_img_from_rect and sample.
252pub fn warp_perspective_gray(
253    src: &GrayImageView<'_>,
254    h_img_from_rect: Homography,
255    out_w: usize,
256    out_h: usize,
257) -> GrayImage {
258    let mut out = vec![0u8; out_w * out_h];
259
260    for y in 0..out_h {
261        for x in 0..out_w {
262            // sample at pixel center
263            let pr = Point2::new(x as f32 + 0.5, y as f32 + 0.5);
264            let pi = h_img_from_rect.apply(pr);
265            let v = sample_bilinear_u8(src, pi.x, pi.y);
266            out[y * out_w + x] = v;
267        }
268    }
269
270    GrayImage {
271        width: out_w,
272        height: out_h,
273        data: out,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn assert_close(a: Point2<f32>, b: Point2<f32>, tol: f32) {
282        let dx = (a.x - b.x).abs();
283        let dy = (a.y - b.y).abs();
284        assert!(
285            dx < tol && dy < tol,
286            "expected ({:.6},{:.6}) ~ ({:.6},{:.6}) within {}",
287            a.x,
288            a.y,
289            b.x,
290            b.y,
291            tol
292        );
293    }
294
295    #[test]
296    fn inverse_round_trips_points() {
297        let h = Homography::new(Matrix3::new(
298            1.2, 0.1, 5.0, //
299            -0.05, 0.9, 3.0, //
300            0.001, 0.0005, 1.0,
301        ));
302        let inv = h.inverse().expect("invertible");
303
304        for p in [
305            Point2::new(0.0_f32, 0.0),
306            Point2::new(50.0_f32, -20.0),
307            Point2::new(320.0_f32, 200.0),
308        ] {
309            let q = h.apply(p);
310            let back = inv.apply(q);
311            assert_close(back, p, 1e-3);
312        }
313    }
314
315    #[test]
316    fn four_point_specialization_recovers_h() {
317        let ground_truth = Homography::new(Matrix3::new(
318            0.8, 0.05, 120.0, //
319            -0.02, 1.1, 80.0, //
320            0.0009, -0.0004, 1.0,
321        ));
322
323        let rect = [
324            Point2::new(0.0_f32, 0.0),
325            Point2::new(180.0_f32, 0.0),
326            Point2::new(180.0_f32, 130.0),
327            Point2::new(0.0_f32, 130.0),
328        ];
329        let dst = rect.map(|p| ground_truth.apply(p));
330
331        let recovered = homography_from_4pt(&rect, &dst).expect("recoverable");
332
333        for p in [
334            Point2::new(0.0_f32, 0.0),
335            Point2::new(60.0, 40.0),
336            Point2::new(150.0, 120.0),
337        ] {
338            assert_close(recovered.apply(p), ground_truth.apply(p), 1e-3);
339        }
340    }
341
342    #[test]
343    fn dlt_handles_overdetermined_case() {
344        let ground_truth = Homography::new(Matrix3::new(
345            1.0, 0.2, 12.0, //
346            -0.1, 0.9, 6.0, //
347            0.0006, 0.0004, 1.0,
348        ));
349
350        let rect: Vec<Point2<f32>> = (0..3)
351            .flat_map(|y| (0..3).map(move |x| Point2::new(x as f32 * 40.0, y as f32 * 50.0)))
352            .collect();
353        let img: Vec<Point2<f32>> = rect.iter().map(|&p| ground_truth.apply(p)).collect();
354
355        let estimated = estimate_homography_rect_to_img(&rect, &img).expect("estimate");
356        for p in [
357            Point2::new(0.0_f32, 0.0),
358            Point2::new(60.0, 40.0),
359            Point2::new(80.0, 90.0),
360            Point2::new(80.0, 100.0),
361        ] {
362            assert_close(estimated.apply(p), ground_truth.apply(p), 1e-3);
363        }
364    }
365
366    #[test]
367    fn mismatched_input_lengths_fail() {
368        let rect = [Point2::new(0.0_f32, 0.0); 4];
369        let img = [Point2::new(1.0_f32, 1.0); 3];
370        assert!(estimate_homography_rect_to_img(&rect, &img).is_none());
371    }
372}